fix(engine): key PostgreSQL progress to every apply, not one slot - #1130
Conversation
One PostgreSQL engine serves a target for its whole lifetime, and it held a single progress slot that each accepted apply took over. A second apply on the same target — a lease handover re-driving work while the previous goroutine still runs — evicted the first one's state, so the running apply's own driver read the idle sentinel and was told its work no longer exists. Track every accepted apply under its own identity instead, so a poller always reads its own schema change. Accepting an apply retires the entries that already reached a terminal state, which bounds the map without ever dropping an in-flight change: a terminal entry is kept only so its driver can read the outcome, and a driver that has accepted another apply on this target has moved past it.
648908d to
d942449
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes PostgreSQL engine progress tracking so concurrent applies on the same long-lived engine instance don’t overwrite each other’s in-memory progress, preventing a running apply from incorrectly observing the idle sentinel (“No active schema change”) when another apply is accepted.
Changes:
- Replace the single progress “slot” with a map keyed by apply identity (
ResumeState.MigrationContext) so each apply reads its own progress. - Retire already-terminal progress entries on acceptance of a new apply to bound memory usage without evicting in-flight entries.
- Expand unit tests to cover concurrent applies, terminal-entry retirement, discard behavior for untracked writes, and drain semantics.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| pkg/engine/postgres/postgres.go | Changes the engine’s in-memory progress storage from a single value to a per-apply map and updates drain semantics accordingly. |
| pkg/engine/postgres/apply.go | Keys Progress, claimProgress, and publishProgress by apply identity; adds terminal-entry retirement and updates discard behavior. |
| pkg/engine/postgres/apply_test.go | Adds/updates tests to prove per-apply isolation, bounded retirement, discard-on-untracked, and drain behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
morgo
left a comment
There was a problem hiding this comment.
🤖 Reviewed on Morgan's behalf by his AI agent. Not approving yet — one question below. Everything else checks out, and this is a clear improvement over the single slot.
What I verified:
- The map is bounded.
claimProgressprunes terminal entries on every accept, so the steady state is{running} ∪ {just-claimed}; terminal entries only accumulate between accepts and the next accept sweeps them. No leak. - No resurrection after retirement. A late
publishProgressfor a pruned key hits the!trackedguard and is discarded rather than re-inserting a dead entry. Drain→nilmap is safe.e.progress[key]on a nil map returns nil, soProgresstakes the idle-sentinel branch, andclaimProgressre-makes the map. No nil-map write.- The idle sentinel string is untouched, so the verbatim comparison stale-task recovery depends on still matches.
The question — terminal retirement across a lease handover. The retirement rule's justification is "a driver that has accepted another apply on this target has moved past it." That's a statement about one driver's sequencing, but the scenario this PR fixes is explicitly a second driver re-driving work after a lease handover while the first goroutine is still executing.
So: driver A's apply reaches terminal. Driver B accepts a new apply on the same target, which retires A's entry. A polls and gets No active schema change instead of A's actual terminal outcome. Running entries are protected from this; terminal ones are not.
The PR body's answer is that such a poller "settles against the target schema, which is authoritative" — and I think that's probably right, since a terminal outcome is recoverable from the schema in a way that a vanished running apply is not. That asymmetry is what makes the trade sound. But I couldn't confirm from this diff that every path polling a terminal PostgreSQL apply actually reaches that settle-from-schema fallback, rather than surfacing the idle sentinel as "your apply disappeared" — which would be the old bug wearing a different hat.
If you can point at the recovery path that absorbs it (or confirm a retired terminal entry is only ever read by a driver that has already recorded the outcome), I'm happy to stamp. Alternatively, keeping terminal entries until Drain would sidestep the question entirely, at the cost of the bound above — and given the map is small and Drain runs on every recovery/re-plan, that cost looks close to nothing.
Style note, non-blocking: the doc comments here are genuinely good — they explain why the slot changed hands rather than restating the code. Worth keeping that bar.
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent. Resolving my own hold — I asked whether a poller reading the idle sentinel for a retired terminal entry actually reaches the settle-from-schema fallback, and said I couldn't confirm it from the diff. I've now traced it in source. Your answer in the PR body was right, and the mechanism is stronger than the description claims.
The path, end to end:
Progressreturns{StatePending, "No active schema change"}for the retired key.engineReportsLostWorkfires, andlostEngineWorkPendingBudgetreturns 0 for this engine precisely because it declaresRegistersWorkSynchronously— soobservePendingreports exhausted on the first report, with no window in which the driver sits on a stale answer.settleLostEngineWorkthen reads the target directly, and its contract is exactly the right one:
A target that already has the desired schema means the work finished and only its outcome was lost: the task completes. A target that still needs the change means the work is genuinely gone: the task is marked retryable so a fresh claim re-drives it — never permanently failed, because nothing about the target is known to be broken.
So the race I was worried about — apply A completes, driver B's accept retires A's terminal entry, A polls for the first time afterwards — lands on schema verification, sees the change present, and completes the task correctly. Not lost, not failed. The retirement is safe.
Worth noting the nice interaction: declaring SynchronousWorkRegistration is what makes this good rather than merely survivable. Without it the driver would spend the full trust budget sitting on an idle sentinel before verifying; with it, verification is immediate. The capability and the retirement rule reinforce each other.
I also checked the one carve-out in settleLostEngineWork — a revert-phase task can't be settled by reading the target, since the forward change already cut over and a schema match proves nothing. That would be a genuine hole in this reasoning, except PostgreSQL doesn't support revert or skip-revert at all (per the envelope doc in #1144), so it isn't reachable for this engine. Worth keeping in mind if PostgreSQL ever grows revert.
Everything from my first review stands: the map is bounded by the prune-on-accept, publishProgress's !tracked guard prevents resurrection, the nil-map read in Progress is safe, and the sentinel string is untouched so the cross-engine contract holds.
|
🤖 Addressing morgo's approval: thank you for tracing the retired-terminal path end to end — your reading matches the intent exactly. The zero trust budget from Housekeeping since the approval: brought the branch up to date by merging Reply generated by Claude Code (Claude Fable 5). |
One PostgreSQL engine serves a target for its whole lifetime, but it held a single progress slot that each accepted apply took over. A second apply on the same target — a lease handover re-driving work while the previous goroutine is still executing — evicted the first one's state, so the running apply's own driver read the idle sentinel and was told its work no longer exists.
Progress is now tracked per apply identity, so a poller always reads its own schema change. This mirrors how the sharded engine already isolates progress per operation, rather than leaving PostgreSQL as the one engine where a sibling can answer for you.
Accepting an apply retires the entries that already reached a terminal state, which bounds the map without ever dropping an in-flight change: a terminal entry is kept only so its own driver can read the outcome, and a driver that has accepted another apply on this target has moved past it. Running entries are never retired.
Drainstill stops tracking everything, and a lookup miss still returns the verbatim idle message that stale-task recovery compares against.With the eviction gone, the engine can declare
SynchronousWorkRegistration: it starts tracking an accepted apply under the engine mutex beforeApplyreturns and executes the statement in a goroutine of this process, so a pending report about work a driver believes is in flight is conclusive rather than a setup phase to wait out. From then on the only writer that stops tracking a running apply isDrain, which means the drive that accepted the work has given it up.Stacked on #1113, which adds the capability interface.
This PR was written by an agent (Claude Opus 5) on Armand's behalf.